跳到主要内容

Java 多线程-ThreadLocal 的使用及原理

快速阅览

ThreadLocal 原理

ThreadLocal,即线程本地变量。如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。

  • Thread 类有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。
  • ThreadLocalMap 内部维护着 Entry 数组,每个 Entry 代表一个完整的对象,key 是 ThreadLocal 本身,value 是 ThreadLocal 的泛型值。
  • 每个线程在往 ThreadLocal 里设置值的时候,都是往自己的 ThreadLocalMap 里存,读也是以某个 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离。

ThreadLocal 内存泄露问题

弱引用比较容易被回收。因此,如果 ThreadLocal(ThreadLocalMap 的 Key)被垃圾回收器回收了,但是因为 ThreadLocalMap 生命周期和 Thread 是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap 的 key 没了,value 还在,这就会「造成了内存泄漏问题」。

如何「解决内存泄漏问题」?使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间。

为什么用弱引用

1、为什么 ThreadLocalMap 使用弱引用存储 ThreadLocal?

如果在 ThreadLocalMap 的 key 使用强引用,是无法完全避免内存泄漏,ThreadLocal 使用完后,ThreadLocal Reference 被回收,但是 Map 的 Entry 强引用了 ThreadLocal,ThreadLocal 就无法被回收,因为强引用链的存在,Entry 无法被回收,最后会导致 Key 内存泄漏。

因此,使用弱引用可以防止长期存在的线程(通常使用了线程池)导致 ThreadLocal 无法回收造成内存泄漏。

2、那通常说的 ThreadLocal 内存泄漏是如何引起的呢?

我们注意到 Entry 对象中,虽然 Key(ThreadLocal) 是通过弱引用引入的,但是 value 即变量值本身是通过强引用引入。

由于 ThreadLocalMap 和线程的生命周期是一致的,当线程资源长期不释放,即使 ThreadLocal 本身由于弱引用机制已经回收掉了,但 value 还是驻留在线程的 ThreadLocalMap 的 Entry 中。即存在 key 为 null,但 value 却有值的无效 Entry。导致内存泄漏。

ThreadLocal 的应用场景

  • 数据库连接池
  • 会话管理中使用

ThreadLocal 线程局部变量

ThreadLocal 类可以给每个线程维护一个独立的变量副本,使多线程的场景使用共有的 ThreadLocal 变量,同时每个线程在 ThreadLocal 对象中保存的变量副本是相互隔离的。

ThreadLocal 变量,它的基本原理是,同一个 ThreadLocal 所包含的对象(对 ThreadLocal< String > 而言即为 String 类型变量),在不同的 Thread 中有不同的副本。这里有几点需要注意

  • 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来
  • 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题
  • 既无共享,何来同步问题,又何来解决同步问题一说?

那 ThreadLocal 到底解决了什么问题,又适用于什么样的场景?

ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被 private static 修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。

使用场景

总的来说,ThreadLocal 适用于 每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。另外,该场景下,并非必须使用 ThreadLocal ,其它方式完全可以实现同样的效果,只是 ThreadLocal 使得实现更简洁。

如果开发者希望将类的某个静态变量(user ID或者transaction ID)与线程状态关联,则可以考虑使用 ThreadLocal。

如上所述,ThreadLocal 适用于如下两种场景

  • 每个线程需要有自己单独的实例
  • 实例需要在多个方法中共享,但不希望被多线程共享

对于第一点,每个线程拥有自己实例,实现它的方式很多。例如可以在线程内部构建一个单独的实例。ThreadLocal 可以以非常方便的形式满足该需求。

对于第二点,可以在满足第一点(每个线程有自己的实例)的条件下,通过方法间引用传递的形式实现(享元模式)。ThreadLocal 使得代码耦合度更低,且实现更优雅。

使用方式

假设,有这样一个类:

@Data
@AllArgsConstructor
public class Counter{
private int count;
}

我们希望多线程访问 Counter 对象时,每个线程各自保留一份 count 计数,那可以这么写:

ThreadLocal<Counter> threadLocal = new ThreadLocal<>();
threadLocal.set(new Counter(0));
Counter counter = threadLocal.get();

如果我们不想每次调用的时候都去初始化,则可以重写 ThreadLocal 的 initValue() 方法给 ThreadLocal 设置一个对象的初始值:

ThreadLocal<Counter> threadLocal = new ThreadLocal<Counter>() {
@Override
protected Counter initialValue() {
return new Counter(0);
}
};

如下所示,这些线程执行的 myTask 对象是同一个,按理说这个 threadLocal 变量会在执行时被其它的线程修改,但是实际输出发现它们都是与当前线程名一致的,说明两个线程在 set 值的时候值是隔离的。进而说明每个线程都创建了一个 threadLocal 变量的副本

public class Temp {
public static void main(String[] args) {
MyTask myTask = new MyTask();
new Thread(myTask, "线程A").start();
new Thread(myTask, "线程B").start();
new Thread(myTask, "线程C").start();
new Thread(myTask, "线程D").start();
new Thread(myTask, "线程E").start();
}

static class MyTask implements Runnable {
// 注意:这里创建的是 static 方法,按理来说整个 JVM 生命周期只有一份,但是使用了这个 ThreadLocal 后每个线程的都是不同的
private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();

@Override
public void run() {
threadLocal.set("当前这个变量值是" + Thread.currentThread().getName());
System.out.println(
Thread.currentThread().getName() + ":" + threadLocal.get() +
" threadLocal的HashCode:" + threadLocal.hashCode() +
" threadLocal内的值的HashCode:" + threadLocal.get().hashCode());

threadLocal.remove(); // 用完要释放掉
}
}
}

输出为:可以发现 threadLocal 的 HashCode都是一样的,只有它们内部的值不一样

线程A:当前这个变量值是线程A   threadLocal的HashCode:493184190   threadLocal内的值的HashCode:1708679936
线程C:当前这个变量值是线程C threadLocal的HashCode:493184190 threadLocal内的值的HashCode:1708679938
线程E:当前这个变量值是线程E threadLocal的HashCode:493184190 threadLocal内的值的HashCode:1708679940
线程D:当前这个变量值是线程D threadLocal的HashCode:493184190 threadLocal内的值的HashCode:1708679939
线程B:当前这个变量值是线程B threadLocal的HashCode:493184190 threadLocal内的值的HashCode:1708679937

实现原理

ThreadLocal 内部维护了一个 Map ,这个 Map 不是直接使用的 HashMap ,而是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类,Map 的 Key 是 ThreadLocal 实例本身,value 是要存储的值。

每个线程内部都有一个 ThreadLocalMap(注意!!!! 这个 Map 是存在线程里面的,所以生命周期和线程一致!!!),Map 里面存放的是 ThreadLocal 对象和线程的变量副本。Thread 内部的 Map 通过 ThreadLocal 对象来维护,向 map 获取和设置变量副本的值。

结构如图:

不同的线程,每次获取变量值时,只能获取自己对象的副本的值。实现了线程之间的数据隔离。

先来看这个 get、set 方法,可以发现它们都是操作线程里面的 ThreadLocalMap

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

JDK1.8 的设计相比于之前的设计(通过 ThreadMap 维护了多个线程和线程变量的对应关系,key 是 Thread 对象,value 是线程变量)的好处在于,每个 Map 存储的 Entry 数量变少了,线程越多键值对越多。

现在的键值对的数量是由 ThreadLocal 的数量决定的,一般情况下 ThreadLocal 的数量少于线程的数量,而且并不是每个线程都需要创建 ThreadLocal 变量。当 Thread 销毁时,ThreadLocalMap 也会随之销毁,减少了内存的使用,之前的方案中线程销毁后,ThreadLocalMap 仍然存在。

public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
// 这里销毁的是 entry for key.
m.remove(this);
}
}

ThreadLocal源码解析

set 方法

首先获取线程,然后获取线程的 Map。

如果 Map 不为空则将当前 ThreadLocal 的引用作为 key 设置到 Map 中(注意!!!这个 Map 来自 Thread)。如果 Map 为空,则创建一个 Map 并设置初始值。

如上述代码所示,我们可以看出来每个线程持有一个 ThreadLocalMap 对象。每创建一个新的线程 Thread 都会实例化一个 ThreadLocalMap 并赋值给成员变量 threadLocals,使用时若已经存在 threadLocals 则直接使用已经存在的对象;否则的话,新创建一个 ThreadLocalMap 并赋值给 threadLocals 变量。

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

如上述代码所示,其为 Thread 类中关于 threadLocals 变量的声明。

get 方法

首先获取当前线程,然后获取 Map。

如果 Map 不为空,则 Map 根据 ThreadLocal 的引用来获取 Entry,如果 Entry 不为空,则获取到 value 值返回。如果 Map 为空或者 Entry 为空,则初始化并获取初始值 value,然后用 ThreadLocal 引用和 value 作为 key 和 value 创建一个新的 Map。

使用这个 ThreadLocal 当 key 是因为线程内部存在一个 Map,而运行时可以使用多个 ThreadLocal 所以需要通过 ThreadLocal 指针来当这个 Map 的 key 来取得对应的 ThreadLocal

这个 Entry 使用弱引用:

static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

所以当时的疑惑,如果要取得的 Entry 被回收了(弱引用),这时调用 get 怎么办,实际上就是通过这个 setInitialValue 方法来保证能取得值的(取得的值为 null 则再次初始化一个值出来 ~)

remove 方法

删除当前线程中保存的 ThreadLocal 对应的实体 Entry

public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
// 这里销毁的是 entry for key.
m.remove(this);
}
}

在学习 Servlet 时了解到,在 doGet()doPost() 方法中,如果使用了 ThreadLocal,但没有清理,那么它的状态很可能会影响到下次的某个请求,因为 Servlet 容器很可能用线程池实现线程复用。

initialValue 方法

该方法的第一次调用发生在当线程通过 get 方法访问线程的 ThreadLocal 值时。除非线程先调用了 set 方法,在这种情况下, initialValue 才不会被这个线程调用。

该方法只返回一个 null,如果想要线程变量有初始值需要通过子类继承 ThreadLocal 的方式去重写此方法,通常可以通过匿名内部类的方式实现。这个方法是 protected 修饰的,是为了让子类覆盖而设计的。

例如如果需要实例化的对象可以使用 ThreadLocal.withInitial 静态方法,例如下面创建一个 SimpleDateFormat 对象

private static final ThreadLocal<SimpleDateFormat> threadLocal = 
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

通常,每个线程最多调用一次此方法,但是在调用 remove 后再调用 get,可以再次调用此方法。

ThreadLocalMap

ThreadLocalMap 是 ThreadLocal 的静态内部类,没有实现 Map 接口,独立实现了 Map 的功能,内部的 Entry 也是独立实现的。

与 HashMap 类似,初始容量默认是16,初始容量必须是2的整数幂。通过 Entry 类的数据 table 存放数据。size 是存放的数量,threshold是扩容阈值。

因此 ThreadLocal 其实只是个符号意义,本身不存储变量,仅仅是用来索引各个线程中的变量副本。

Entry 继承自 WeakReference,key 是弱引用,其目的是将 ThreadLocal 对象的生命周期和线程生命周期解绑。

弱引用是啥?

Java 语言中为对象的引用分为了四个级别,分别为 强引用 、软引用、弱引用、虚引用。弱引用具体指的是 java.lang.ref.WeakReference 类。

对对象进行弱引用不会影响垃圾回收器回收该对象,即如果一个对象只有弱引用存在了,则下次 GC 将会回收掉该对象(不管当前内存空间足够与否)。

再来说说内存泄漏,假如一个短生命周期的对象被一个长生命周期对象长期持有引用,将会导致该短生命周期对象使用完之后得不到释放,从而导致内存泄漏。

因此,弱引用的作用就体现出来了,可以使用弱引用来引用短生命周期对象,这样不会对垃圾回收器回收它造成影响,从而防止内存泄漏。

弱引用和内存泄漏是啥?

内存溢出:没有足够的内存供申请者提供

内存泄漏:程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等验证后沟。内存泄漏的堆积会导致内存溢出。

弱引用:垃圾回收器一旦发现了弱引用的对象,不管内存是否足够,都会回收它的内存。

内存泄漏的根源是 ThreadLocalMap 和 Thread 的生命周期是一样长的。

Entry 弱引用的作用

1、为什么 ThreadLocalMap 使用弱引用存储 ThreadLocal?

如果在 ThreadLocalMap 的 key 使用强引用还是无法完全避免内存泄漏,ThreadLocal 使用完后,ThreadLocal Reference 被回收,但是 Map 的 Entry 强引用了 ThreadLocal,ThreadLocal 就无法被回收,因为强引用链的存在,Entry 无法被回收,最后会内存泄漏。

因此,使用弱引用可以防止长期存在的线程(通常使用了线程池)导致 ThreadLocal 无法回收造成内存泄漏。

2、那通常说的 ThreadLocal 内存泄漏是如何引起的呢?

我们注意到 Entry 对象中,虽然 Key(ThreadLocal) 是通过弱引用引入的,但是 value 即变量值本身是通过强引用引入。

由于 ThreadLocalMap 和线程的生命周期是一致的,当线程资源长期不释放,即使 ThreadLocal 本身由于弱引用机制已经回收掉了,但 value 还是驻留在线程的 ThreadLocalMap 的 Entry 中。即存在 key 为 null,但 value 却有值的无效 Entry。导致内存泄漏。

但实际上,ThreadLocal 内部已经为我们做了一定的防止内存泄漏的工作。

即如下方法:

/**
* Expunge a stale entry by rehashing any possibly colliding entries
* lying between staleSlot and the next null slot. This also expunges
* any other stale entries encountered before the trailing null. See
* Knuth, Section 6.4
*
* @param staleSlot index of slot known to have null key
* @return the index of the next null slot after staleSlot
* (all between staleSlot and this slot will have been checked
* for expunging).
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;

for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

上述方法的作用是擦除某个下标的 Entry(置为 null,可以回收),同时检测整个 Entry[] 表中对 key 为 null 的 Entry 一并擦除,重新调整索引。

该方法,在每次调用 ThreadLocal 的 get、set、remove 方法时都会执行,即 ThreadLocal 内部已经帮我们做了对 key 为 null 的 Entry 的清理工作。

但是该工作是有触发条件的,需要调用相应方法,假如我们使用完之后不做任何处理是不会触发的。

所以在代码逻辑中使用完 ThreadLocal,都要调用remove方法,及时清理。

目前我们使用多线程都是通过线程池管理的,对于核心线程数之内的线程都是长期驻留池内的。显式调用 remove,一方面是防止内存泄漏,最为重要的是,不及时清除有可能导致严重的业务逻辑问题,产生线上故障(使用了上次未清除的值)。

最佳实践:在 ThreadLocal 使用前后都调用 remove 清理,同时对异常情况也要在 finally 中清理。

ThreadLocalMap 的构造方法

如下所示:

构造函数创建一个长队为16的 Entry 数组,然后计算 firstKey 的索引,存储到 table 中,设置 size 和 threshold。

firstKey.threadLocalHashCode & (INITIAL_CAPACITY-1) 用来计算索引,nextHashCode 是 Atomicinteger 类型的,Atomicinteger 类是提供原子操作的 Integer 类,通过线程安全的方式来加减,适合高并发使用。

每次在当前值上加上一个 HASH_INCREMENT 值,这个值和斐波拉契数列有关,主要目的是为了让哈希码可以均匀的分布在2的n次方的数组里,从而尽量的避免冲突。

当 size为 2的幂次的时候,hashCode & (size - 1) 相当于取模运算 hashCode % size,位运算比取模更高效一些。为了使用这种取模运算, 所有 size 必须是 2的幂次。这样一来,在保证索引不越界的情况下,减少冲突的次数。

ThreadLocalMap的 set 方法

Map 的 key 是啥?

首先看下这个 get 方法,可以发现它内部调用的 map.getEntry(this) 说明 key 是以当前 ThreadLocal 实例对象

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

从 ThreadLocalMap 中取数据,有数据就返回,没有数据就设置默认值并返回(所以说 initialValue 是延迟调用)。

value 为什么是强引用

不设置为弱引用,是因为不清楚这个 Value 除了 map 的引用还是否还存在其他引用,如果不存在其他引用,当 GC 的时候就会直接将这个 Value 干掉了,而此时我们的 ThreadLocal 还处于使用期间,就会造成 Value 为 null 的错误,所以将其设置为强引用。

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

而为了解决这个强引用得不到释放的问题(就是等太久 Key 已经被释放了,强引用还没释放),它提供了一种机制就是上面说的将 Key 为 Null 的 Entity 直接清除

Hash 冲突的解决

ThreadLocalMao 使用了线性探测法来解决冲突。线性探测法探测下一个地址,找到空的地址则插入,若整个空间都没有空余地址,则产生溢出。例如:长度为8的数组中,当前key的hash值是6,6的位置已经被占用了,则hash值加一,寻找7的位置,7的位置也被占用了,回到0的位置。直到可以插入为止,可以将这个数组看成一个环形数组。

Reference

参考资料 详述 ThreadLocal 的实现原理及其使用方法 参考资料 Java多线程之深入解析ThreadLocal和ThreadLocalMap 参考资料 ThreadLocal为啥要用弱引用?不知道 参考资料 Java进阶(七)正确理解Thread Local的原理与适用场景 参考资料 ThreadLocal弱引用与内存泄漏分析